"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import type { ChapterDraft } from "@/lib/schemas/ebook"; import { getAudioPins, addAudioPin, removeAudioPin, } from "@/lib/reader-store"; import type { AudioPin } from "@/lib/reader-store"; import { useAudioPlayer, RATES, NARRATOR_PERSONAS, type Segment, type SegmentType, type AudioChapterMeta, } from "@/lib/audio-player-context"; // Re-export types so existing imports in ReaderClient don't break export type { Segment, SegmentType }; // AudioState is used by the UI render below type AudioState = "idle" | "playing" | "paused"; // ── Scripture reference expansion ──────────────────────────────────────────── // Must run BEFORE all other sanitization so "John 3:16" is never handed to the // TTS engine as a bare "3:16" time token (which browsers read as "3 hours // 16 minutes"). Handles full names, common abbreviations, numbered books, and // optional verse ranges (e.g. John 3:16-17 or Rom 8:1-2:5). const BIBLE_BOOKS = [ // Old Testament — full names "Genesis","Exodus","Leviticus","Numbers","Deuteronomy","Joshua","Judges","Ruth", "Samuel","Kings","Chronicles","Ezra","Nehemiah","Esther","Job","Psalms","Psalm", "Proverbs","Ecclesiastes","Isaiah","Jeremiah","Lamentations","Ezekiel","Daniel", "Hosea","Joel","Amos","Obadiah","Jonah","Micah","Nahum","Habakkuk","Zephaniah", "Haggai","Zechariah","Malachi", // New Testament — full names "Matthew","Mark","Luke","John","Acts","Romans","Corinthians","Galatians", "Ephesians","Philippians","Colossians","Thessalonians","Timothy","Titus", "Philemon","Hebrews","James","Peter","Jude","Revelation", // Old Testament — abbreviations "Gen","Exod","Exo","Ex","Lev","Num","Deut","Deu","Josh","Judg","Sam", "Kgs","Chr","Neh","Est","Psa","Ps","Prov","Pro","Eccl","Ecc","Song", "Isa","Jer","Lam","Ezek","Eze","Dan","Hos","Obad","Jon","Mic","Nah", "Hab","Zeph","Hag","Zech","Zec","Mal", // New Testament — abbreviations "Matt","Mt","Mk","Lk","Jn","Rom","Gal","Eph","Phil","Col","Thess", "Tim","Tit","Philem","Phlm","Heb","Jas","Rev", ].join("|"); // Matches: [1/2/3 ]BookName chapter:verse[-verse|-chapter:verse] const SCRIPTURE_RE = new RegExp( `\\b((?:[123]\\s+)?(?:${BIBLE_BOOKS}))\\s+(\\d{1,3}):(\\d{1,3})(?:\\s*[-\u2013]\\s*(\\d{1,3}(?::\\d{1,3})?))?`, "gi", ); function expandScripture( _: string, rawBook: string, ch: string, v1: string, end?: string, ): string { const book = rawBook .replace(/^1\s+/i, "First ") .replace(/^2\s+/i, "Second ") .replace(/^3\s+/i, "Third "); if (!end) return `${book} chapter ${ch} verse ${v1}`; if (end.includes(":")) { const [ch2, v2] = end.split(":"); return `${book} chapter ${ch} verse ${v1} through chapter ${ch2} verse ${v2}`; } return `${book} chapter ${ch} verses ${v1} to ${end}`; } // ── 1. Text pre-processing — clean abbreviations, punctuation, noise ────────── function sanitizeForSpeech(text: string): string { return text // Scripture references must come first — before any colon or number handling .replace(SCRIPTURE_RE, expandScripture as Parameters[1]) // common abbreviations → full words (prevents "e dot g dot" robot reading) .replace(/\be\.g\./gi, "for example") .replace(/\bi\.e\./gi, "that is") .replace(/\betc\./gi, "and so on") .replace(/\bvs\./gi, "versus") .replace(/\bDr\./g, "Doctor") .replace(/\bMr\./g, "Mister") .replace(/\bMrs\./g, "Missus") .replace(/\bMs\./g, "Miss") .replace(/\bProf\./g, "Professor") .replace(/\bSt\./g, "Saint") // em-dash → comma pause (natural breathing) .replace(/\s*—\s*/g, ", ") // ellipsis → sentence pause .replace(/…/g, ". ") .replace(/\.\.\./g, ". ") // backtick code → plain text .replace(/`([^`]+)`/g, "$1") // URLs → silence .replace(/https?:\/\/\S+/g, "") // numeric citations [1], [2], footnote refs .replace(/\[\d+\]/g, "") .replace(/\[citation[^\]]*\]/gi, "") // page refs like (pg. 12) or (p. 3) .replace(/\(p(?:g)?\.?\s*\d+\)/gi, "") // strip remaining markdown bold/italic .replace(/\*\*\*([^*]+)\*\*\*/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") // Normalize question marks → period so TTS doesn't apply rising pitch // inflection. A narrator reads rhetorical and genuine questions with an // even, measured tone — the exaggerated upswing sounds robotic. .replace(/\?+/g, ".") .trim(); } // ── 2. Sentence-level splitting — each sentence = one utterance ─────────────── // Resets charIndex per sentence → more accurate word-boundary tracking. function splitSentences(text: string): string[] { // Split after . ! ? when followed by whitespace + capital letter or quote const parts = text.split(/(?<=[.!?])\s+(?=[A-Z"'\u201C\u2018])/); return parts.map(s => s.trim()).filter(Boolean); } // ── Parse chapter into typed segments ───────────────────────────────────────── // Also exported so ReaderClient can build a paraKey→segIdx map for click-to-start. // ── Emphasis detection ─────────────────────────────────────────────────────── // Splits raw markdown text into alternating normal / emphasis spans BEFORE // markdown is stripped. Emphasis sources: // • ***bold-italic*** / **bold** / *italic* // • ALL-CAPS runs of 3+ characters (e.g. "GOD", "LOVE", "THE TRUTH") // Returns an array of { text, emph } objects. The caller creates "emphasis" // segments for emph=true spans so the TTS engine pitches them higher. // Only markdown markers are detected — no ALL-CAPS heuristic. const EMPH_RE = /(\*{1,3}[^*\n]+?\*{1,3})/g; function splitEmphasis(raw: string): { text: string; emph: boolean }[] { const parts: { text: string; emph: boolean }[] = []; let last = 0; for (const m of raw.matchAll(EMPH_RE)) { if (m.index! > last) parts.push({ text: raw.slice(last, m.index!), emph: false }); // Strip surrounding asterisks to get the plain text const text = m[1].replace(/^\*{1,3}/, "").replace(/\*{1,3}$/, ""); if (text.trim()) parts.push({ text, emph: true }); last = m.index! + m[0].length; } if (last < raw.length) parts.push({ text: raw.slice(last), emph: false }); return parts.filter((p) => p.text.trim().length > 0); } function estimateSegmentDuration(segment: Segment): number { const words = segment.text.split(/\s+/).filter(Boolean).length; const chars = segment.text.length; const commas = (segment.text.match(/,/g) ?? []).length; const semicolons = (segment.text.match(/[;:]/g) ?? []).length; const clauses = (segment.text.match(/[.!?]/g) ?? []).length; let seconds = Math.max(0.55, words * 0.28 + chars * 0.004); if (segment.type === "chapter-title") seconds *= 1.9; else if (segment.type === "heading") seconds *= 1.35; else if (segment.type === "quote") seconds *= 1.12; else if (segment.type === "emphasis") seconds *= 1.05; seconds += commas * 0.18 + semicolons * 0.24 + clauses * 0.12; if (words <= 4) seconds += 0.12; if (words >= 25) seconds += 0.4; return Math.max(0.45, seconds); } function buildRecordedTimeline(segments: Segment[], duration: number): Array<{ start: number; end: number; paraKey: string }> { if (!segments.length || !Number.isFinite(duration) || duration <= 0) return []; const estimated = segments.map((segment) => estimateSegmentDuration(segment)); const totalEstimated = estimated.reduce((sum, value) => sum + value, 0) || 1; const scale = duration / totalEstimated; let cursor = 0; return segments.map((segment, index) => { const segmentDuration = Math.max(0.45, estimated[index] * scale); const entry = { start: cursor, end: cursor + segmentDuration, paraKey: segment.paraKey }; cursor = entry.end; return entry; }).map((entry, index, timeline) => { if (index === timeline.length - 1) { return { ...entry, end: duration }; } return entry; }); } export function parseChapter(chapter: ChapterDraft): Segment[] { const segs: Segment[] = []; const stripMd = (s: string) => s.replace(/\*\*\*([^*]+)\*\*\*/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .trim(); const pushSentences = (type: SegmentType, raw: string, paraKey: string) => { if (type === "heading" || type === "chapter-title") { const clean = sanitizeForSpeech(stripMd(raw)); if (clean) segs.push({ type, text: clean, paraKey }); return; } // Split on emphasis markers/ALL-CAPS before stripping markdown so the // prosody treatment is preserved per span. const parts = splitEmphasis(raw); for (const { text, emph } of parts) { const clean = sanitizeForSpeech(stripMd(text)); if (!clean.trim()) continue; const segType: SegmentType = emph ? "emphasis" : type; for (const sentence of splitSentences(clean)) { if (sentence.trim()) segs.push({ type: segType, text: sentence.trim(), paraKey }); } } }; // Mirrors the block-grouping logic in renderBody so paraKeys align between // AudioReader segments and data-pkey attributes on rendered DOM elements. const processBody = (text: string, prefix: string) => { const lines = text.split("\n"); let blockIdx = 0; let i = 0; while (i < lines.length) { const line = lines[i].trim(); if (!line || /^---+$/.test(line)) { i++; continue; } const key = `${prefix}_b${blockIdx}`; if (/^#{1,3} /.test(line)) { pushSentences("heading", line.replace(/^#{1,3} /, ""), key); blockIdx++; i++; continue; } if (/^> /.test(line)) { // Group consecutive quote lines — same key, matching renderBody's
while (i < lines.length && /^> /.test(lines[i].trim())) { pushSentences("quote", lines[i].trim().slice(2), key); i++; } blockIdx++; continue; } if (/^[*-] /.test(line)) { while (i < lines.length && /^[*-] /.test(lines[i].trim())) { pushSentences("body", lines[i].trim().slice(2), key); i++; } blockIdx++; continue; } if (/^\d+\. /.test(line)) { while (i < lines.length && /^\d+\. /.test(lines[i].trim())) { pushSentences("body", lines[i].trim().replace(/^\d+\. /, ""), key); i++; } blockIdx++; continue; } pushSentences("body", line, key); blockIdx++; i++; } }; const chapterLead = chapter.number > 0 ? `Chapter ${chapter.number}. ${chapter.title}.` : `${chapter.title}.`; pushSentences("chapter-title", chapterLead, "title"); if (chapter.epigraph) pushSentences("quote", chapter.epigraph, "epigraph"); if (chapter.intro) pushSentences("body", chapter.intro, "intro"); for (let si = 0; si < chapter.sections.length; si++) { const section = chapter.sections[si]; // Mirror ReaderClient: the first section heading is intentionally hidden // in the UI, so it should not be narrated as an apparent "extra subtitle". if (section.heading && si > 0) pushSentences("heading", section.heading, `s${si}_h`); processBody(section.body, `s${si}`); } if (chapter.forwardQuestion) pushSentences("body", chapter.forwardQuestion, "fwd"); return segs.filter(s => s.text.length > 0); } // ── 5. Voice selection — prefers enhanced/neural voices ────────────────────── // ── Props ───────────────────────────────────────────────────────────────────── interface Props { chapter: ChapterDraft; /** Book-level metadata forwarded to the global audio context. */ bookTitle: string; /** Absolute pathname back to this reader, e.g. /library/my-book/read */ readerHref: string; /** Book slug — used to persist audio pins. */ slug: string; /** Generated chapter narration URL from Voice Studio local storage. */ chapterAudioUrl?: string | null; /** External seek request for recorded audio (segment + word offset). */ recordedSeekRequest?: { token: number; segIdx: number; wordIdx: number } | null; /** Emits active paragraph key while recorded audio plays. */ onRecordedParaKeyChange?: (paraKey: string | null) => void; theme: { muted: string; accent: string; chrome: string; chromeBorder: string; text: string; border: string; bg: string; }; fontFamily: string; onClose: () => void; /** Jump to this segment index (e.g. from a tap-to-start paragraph click). */ startFrom?: number; } export function AudioReader({ chapter, bookTitle, readerHref, slug, chapterAudioUrl, recordedSeekRequest, onRecordedParaKeyChange, theme, fontFamily, onClose, startFrom, }: Props) { const { state, currentSeg, currentWord, segIdx, segTotal, rateIdx, setChapter, play, pause, resume, stop, cycleRate, seekTo, setVolumeMultiplier, setPersona, } = useAudioPlayer(); const prevStartFrom = useRef(undefined); const recordedAudioRef = useRef(null); const recordedTimelineRef = useRef>([]); const hasRecordedAudio = Boolean(chapterAudioUrl); // ── Narrator persona — persisted to localStorage ───────────────────────── // localPersonaIdx drives the UI; a useEffect syncs it into the engine. const [localPersonaIdx, setLocalPersonaIdx] = useState(() => { if (typeof window === "undefined") return 0; const v = parseInt(localStorage.getItem("nd_narrator_persona") ?? "0", 10); return isNaN(v) ? 0 : Math.max(0, Math.min(NARRATOR_PERSONAS.length - 1, v)); }); // Sync to engine whenever the local value changes (includes initial mount) useEffect(() => { setPersona(localPersonaIdx); }, [localPersonaIdx, setPersona]); const handleCyclePersona = useCallback(() => { const next = (localPersonaIdx + 1) % NARRATOR_PERSONAS.length; setLocalPersonaIdx(next); localStorage.setItem("nd_narrator_persona", String(next)); }, [localPersonaIdx]); // ── Sleep timer ──────────────────────────────────────────────────────── const SLEEP_OPTIONS = [0, 10, 20, 30, 60] as const; type SleepMins = typeof SLEEP_OPTIONS[number]; const [sleepMins, setSleepMins] = useState(0); const [sleepSecsLeft, setSleepSecsLeft] = useState(0); const sleepIntervalRef = useRef | null>(null); // Cycle through sleep options const cycleSleep = useCallback(() => { setSleepMins((prev) => { const idx = SLEEP_OPTIONS.indexOf(prev); const next = SLEEP_OPTIONS[(idx + 1) % SLEEP_OPTIONS.length]; if (next === 0) { setSleepSecsLeft(0); setVolumeMultiplier(1); } else { setSleepSecsLeft(next * 60); } return next; }); }, [setVolumeMultiplier]); // Countdown tick useEffect(() => { if (sleepMins === 0) { if (sleepIntervalRef.current) { clearInterval(sleepIntervalRef.current); sleepIntervalRef.current = null; } return; } sleepIntervalRef.current = setInterval(() => { setSleepSecsLeft((prev) => { const next = prev - 1; if (next <= 0) { pause(); setVolumeMultiplier(1); setSleepMins(0); return 0; } // Fade volume in last 90 seconds setVolumeMultiplier(next <= 90 ? next / 90 : 1); return next; }); }, 1000); return () => { if (sleepIntervalRef.current) clearInterval(sleepIntervalRef.current); }; // Only re-run when sleepMins changes (not on every re-render) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sleepMins]); // Sleep timer display label const sleepFading = sleepMins > 0 && sleepSecsLeft <= 90; const sleepLabel = sleepMins === 0 ? null : sleepFading ? `${sleepSecsLeft}s` : `${Math.ceil(sleepSecsLeft / 60)}m`; // ── Audio pins ──────────────────────────────────────────────────────── const chapterKey = `${slug}-ch-${chapter.number}`; const [pins, setPins] = useState([]); // Load pins for this chapter useEffect(() => { setPins(getAudioPins(slug, chapterKey)); }, [slug, chapterKey]); const isPinnedHere = pins.some((p) => p.segIdx === segIdx); const togglePin = useCallback(() => { const existing = pins.find((p) => p.segIdx === segIdx); if (existing) { removeAudioPin(slug, chapterKey, existing.id); } else { addAudioPin(slug, chapterKey, { id: crypto.randomUUID(), segIdx, label: currentSeg?.text?.slice(0, 60) ?? `Segment ${segIdx + 1}`, addedAt: new Date().toISOString(), }); } setPins(getAudioPins(slug, chapterKey)); }, [slug, chapterKey, segIdx, pins, currentSeg]); // Register chapter with the global engine when chapter changes useEffect(() => { if (hasRecordedAudio) return; const segs: Segment[] = parseChapter(chapter); const meta: AudioChapterMeta = { chapterKey: `${slug}-ch-${chapter.number}`, title: chapter.title, number: chapter.number, bookTitle, readerHref, }; setChapter(segs, meta); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chapter, bookTitle, readerHref, slug, hasRecordedAudio]); // Jump to a specific segment when startFrom prop changes useEffect(() => { if (hasRecordedAudio) return; if (startFrom === undefined) return; if (startFrom === prevStartFrom.current) return; prevStartFrom.current = startFrom; seekTo(startFrom); }, [startFrom, seekTo, hasRecordedAudio]); useEffect(() => { if (!hasRecordedAudio) { recordedTimelineRef.current = []; onRecordedParaKeyChange?.(null); return; } const audioEl = recordedAudioRef.current; if (!audioEl) return; audioEl.muted = false; audioEl.volume = 1; const syncTimeline = () => { const segs = parseChapter(chapter); recordedTimelineRef.current = buildRecordedTimeline(segs, audioEl.duration || 0); }; audioEl.addEventListener("loadedmetadata", syncTimeline); if (audioEl.readyState >= 1) syncTimeline(); return () => audioEl.removeEventListener("loadedmetadata", syncTimeline); }, [chapter, hasRecordedAudio, chapterAudioUrl, onRecordedParaKeyChange]); useEffect(() => { if (!hasRecordedAudio) return; const audioEl = recordedAudioRef.current; if (!audioEl) return; const emitCurrentPara = () => { const timeline = recordedTimelineRef.current; if (!timeline.length) { onRecordedParaKeyChange?.(null); return; } const t = audioEl.currentTime; const active = timeline.find((entry) => t >= entry.start && t < entry.end) ?? timeline[timeline.length - 1]; onRecordedParaKeyChange?.(active?.paraKey ?? null); }; const clearPara = () => onRecordedParaKeyChange?.(null); audioEl.addEventListener("timeupdate", emitCurrentPara); audioEl.addEventListener("seeked", emitCurrentPara); audioEl.addEventListener("play", emitCurrentPara); audioEl.addEventListener("ended", clearPara); emitCurrentPara(); return () => { audioEl.removeEventListener("timeupdate", emitCurrentPara); audioEl.removeEventListener("seeked", emitCurrentPara); audioEl.removeEventListener("play", emitCurrentPara); audioEl.removeEventListener("ended", clearPara); }; }, [hasRecordedAudio, onRecordedParaKeyChange]); useEffect(() => { if (!hasRecordedAudio || !recordedSeekRequest) return; const audioEl = recordedAudioRef.current; const timeline = recordedTimelineRef.current; if (!audioEl || !timeline.length) return; const segmentWindow = timeline[recordedSeekRequest.segIdx]; if (!segmentWindow) return; const segs = parseChapter(chapter); const tappedSeg = segs[recordedSeekRequest.segIdx]; const words = tappedSeg?.text.split(/\s+/).filter(Boolean).length ?? 0; const ratio = words > 0 ? Math.min(1, Math.max(0, recordedSeekRequest.wordIdx / words)) : 0; const seekSeconds = segmentWindow.start + (segmentWindow.end - segmentWindow.start) * ratio; audioEl.currentTime = Math.max(0, seekSeconds); void audioEl.play().catch(() => {}); }, [chapter, hasRecordedAudio, recordedSeekRequest]); const togglePlay = () => { if (hasRecordedAudio) { const audioEl = recordedAudioRef.current; if (!audioEl) return; if (audioEl.paused) { void audioEl.play().catch(() => {}); } else { audioEl.pause(); } return; } if (!window.speechSynthesis) return; if (state === "idle") play(); else if (state === "playing") pause(); else if (state === "paused") resume(); }; const wordTokens = currentSeg ? currentSeg.text.split(/(\s+)/).filter(t => !/^\s+$/.test(t)) : []; const segBadge: Record = { "chapter-title": { label: "Chapter", color: theme.accent }, "heading": { label: "Section", color: theme.accent }, "quote": { label: "Quote", color: "#0ea5e9" }, "body": { label: "Narration", color: theme.muted }, "emphasis": { label: "Emphasis", color: "#f59e0b" }, }; return (
{chapterAudioUrl && (

Recorded chapter audio

{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
)} {hasRecordedAudio ? (

Chapter {chapter.number}

Recorded narration

) : ( <> {/* ── CSS keyframes for equalizer bars ── */} {/* ── Chapter progress bar + pin markers ── */}
{/* Fill bar */}
0 ? `${Math.round((segIdx / segTotal) * 100)}%` : "0%", background: `linear-gradient(to right, ${theme.accent}bb, ${theme.accent})`, transition: "width 0.5s ease", }} /> {/* Pin markers — clickable amber diamonds */} {segTotal > 0 && pins.map((pin) => (
{/* ── Now-reading strip ── */} {currentSeg && state !== "idle" && (
{/* Meta row: EQ bars + segment label + position counter */}
{/* Animated equalizer */}
{[ { anim: "nxEqA", dur: "0.75s", delay: "0ms", h: 4 }, { anim: "nxEqB", dur: "0.9s", delay: "130ms", h: 8 }, { anim: "nxEqC", dur: "0.65s", delay: "260ms", h: 5 }, ].map((bar, i) => (
))}
{/* Segment type label */} {segBadge[currentSeg.type].label} {/* Position counter */} {segIdx + 1}{" "} /{" "} {segTotal}
{/* Word-highlighted sentence */}

{wordTokens.map((word, i) => ( {word}{" "} ))}

)} {/* ── Controls bar ── */}
{/* Track info */}

Chapter {chapter.number}

{state === "playing" ? "Now reading…" : state === "paused" ? "Paused" : chapter.title}

{/* Speed pill */} {/* Narrator persona — cycles Balanced → Storyteller → Preacher → Podcast */} {/* Sleep timer — moon button */} {/* Pin / bookmark current position */} {/* Stop */} {state !== "idle" && ( )} {/* Play / Pause */} {/* Close — hides bar only; audio continues in GlobalMiniPlayer */}
)}
); }